Перейти к основному содержимому

5.19. Типы данных

Разработчику Архитектору

Типы данных

Типы данных в Elixir делятся на две большие категории: базовые (или примитивные) и составные. Базовые типы представляют собой простые значения, которые не содержат внутри себя других структур. Составные типы строятся из базовых или других составных элементов и позволяют моделировать сложные формы информации. Все типы в Elixir реализованы как значения, передаваемые по значению, а не по ссылке, что упрощает рассуждения о поведении программы и делает её более предсказуемой.

Целые числа

Целые числа в Elixir — это значения без дробной части, которые могут быть как положительными, так и отрицательными. Язык поддерживает произвольную точность целых чисел, что означает отсутствие ограничений на размер числа, кроме доступной памяти. Разработчик может свободно работать с числами, содержащими тысячи или даже миллионы цифр, без необходимости использовать специальные библиотеки. Такая гибкость особенно полезна в криптографии, математических вычислениях и других областях, где требуется высокая точность.

Целые числа можно записывать в десятичной, шестнадцатеричной, восьмеричной и двоичной системах счисления. Для этого используются префиксы: 0x для шестнадцатеричных, 0o для восьмеричных и 0b для двоичных литералов. Например, число 255 может быть записано как 0xFF, 0o377 или 0b11111111. Все эти формы эквивалентны и интерпретируются одинаково во время выполнения программы.

Числа с плавающей точкой

Числа с плавающей точкой в Elixir соответствуют стандарту IEEE 754 двойной точности (64-битные). Они используются для представления вещественных чисел, то есть значений, которые могут содержать дробную часть. Запись таких чисел осуществляется в десятичной форме с обязательным наличием точки, даже если дробная часть отсутствует. Например, 3.0 — корректное число с плавающей точкой, тогда как 3 будет воспринято как целое число.

Арифметические операции с числами с плавающей точкой выполняются с использованием аппаратной поддержки процессора, если она доступна. Однако, как и в большинстве языков программирования, такие операции могут быть подвержены ошибкам округления из-за особенностей двоичного представления десятичных дробей. Это требует внимания при сравнении значений или выполнении финансовых расчётов, где важна абсолютная точность.

Атомы

Атомы — одна из самых характерных черт Elixir и всей экосистемы Erlang. Атом представляет собой константу, имя которой одновременно является её значением. Атомы начинаются с двоеточия, например: :ok, :error, :user_id. Они часто используются для обозначения состояний, меток, ключей в структурах данных или возвращаемых значений функций. Поскольку атомы неизменяемы и глобально уникальны, их сравнение происходит за константное время, что делает их чрезвычайно эффективными для использования в условиях высокой нагрузки.

Важно понимать, что атомы хранятся в специальной таблице атомов, которая имеет ограниченный размер. Создание слишком большого количества атомов во время выполнения программы может привести к исчерпанию этой таблицы и аварийному завершению системы. По этой причине атомы не должны создаваться динамически из ненадёжных источников, таких как пользовательский ввод. Вместо этого рекомендуется использовать строки или другие типы данных, когда требуется гибкость в формировании имён.

Строки

Строки в Elixir — это последовательности байтов, закодированные в формате UTF-8. Это означает, что каждая строка является бинарным объектом, который может содержать любой текст, включая символы всех известных языков мира, эмодзи и специальные символы. Строки записываются в двойных кавыках: "Привет, мир!".

Поскольку строки в Elixir реализованы как бинарники, они поддерживают все операции, доступные для бинарных данных. При этом язык предоставляет богатый набор функций для работы с текстом: поиск подстрок, замена, разбиение, преобразование регистра и многое другое. Эти функции находятся в модуле String, который является частью стандартной библиотеки.

Особенностью строк в Elixir является их неизменяемость. Любая операция, изменяющая строку, создаёт новый бинарный объект. Это может показаться неэффективным на первый взгляд, но благодаря механизму копирования при записи (copy-on-write) и оптимизациям на уровне BEAM, такие операции выполняются быстро и с минимальным расходом памяти.

Символы и кодовые точки

Хотя Elixir не имеет отдельного типа «символ» в том виде, в каком он существует в некоторых других языках, он предоставляет работу с кодовыми точками Unicode. Каждый символ в строке представлен своей кодовой точкой — целым числом, соответствующим стандарту Unicode. Функции из модуля String позволяют перебирать строку по кодовым точкам, получать их числовые значения и выполнять различные преобразования.

Для удобства записи отдельных символов используется синтаксис ?a, который возвращает кодовую точку символа a (в данном случае — 97). Этот подход позволяет легко работать с отдельными символами без необходимости извлекать их из строк.


Кортежи (Tuples)

Кортеж — это упорядоченная коллекция элементов фиксированного размера. Кортежи в Elixir записываются в фигурных скобках, например: {:ok, "файл загружен"} или {1, 2, 3}. Каждый элемент может быть любого типа: число, строка, атом, другой кортеж — ограничений нет. Размер кортежа определяется во время его создания и не может быть изменён позже.

Кортежи часто используются для возврата нескольких значений из функции, особенно для передачи результата операции вместе со статусом. Стандартная практика в экосистеме Elixir — возвращать пару вида {:ok, значение} при успехе и {:error, причина} при ошибке. Такой подход делает обработку исключений явной и предсказуемой.

Пример:

File.read("example.txt")
# Может вернуть:
# {:ok, "Содержимое файла"}
# или
# {:error, :enoent}

Доступ к элементам кортежа осуществляется по индексу с помощью функции elem/2:

data = {:user, "Алексей", 34}
name = elem(data, 1) # "Алексей"
age = elem(data, 2) # 34

Изменение элемента кортежа невозможно напрямую, поскольку все данные в Elixir неизменяемы. Однако можно создать новый кортеж на основе старого с помощью синтаксиса обновления:

person = {:user, "Мария", 28}
updated_person = put_elem(person, 2, 29) # {:user, "Мария", 29}

Функция put_elem/3 принимает исходный кортеж, индекс и новое значение, возвращая новый кортеж. Это соответствует общей философии языка: вместо модификации — создание нового значения.


Списки (Lists)

Список в Elixir — это односвязный список, реализованный как цепочка пар «голова–хвост». Голова содержит первый элемент, хвост — оставшуюся часть списка (или пустой список, если элемент последний). Списки записываются в квадратных скобках: [1, 2, 3], ["привет", "мир"].

Особенность списков — эффективное добавление элементов в начало. Операция [элемент | список] выполняется за константное время. Добавление в конец или произвольный доступ по индексу требует прохода по всему списку и имеет линейную сложность.

Примеры:

list = [1, 2, 3]
new_list = [0 | list] # [0, 1, 2, 3]

# Разбор списка через сопоставление с образцом
[head | tail] = new_list
# head == 0
# tail == [1, 2, 3]

Списки могут содержать элементы разных типов:

mixed = [:status, "готово", 200]

Однако в реальных программах рекомендуется использовать однородные списки для повышения читаемости и поддержки типовой дисциплины.

Проверка принадлежности элемента списку:

1 in [1, 2, 3]  # true
5 in [1, 2, 3] # false

Длина списка определяется функцией length/1:

length([:a, :b, :c])  # 3

Важно не путать списки с кортежами: списки предназначены для хранения последовательностей переменной длины, кортежи — для фиксированных наборов связанных значений.


Бинарники (Binaries)

Бинарник — это последовательность байтов. Он представляет собой непрерывный блок памяти, используемый для хранения сырых данных: изображений, сетевых пакетов, сериализованных структур и, что особенно важно, строк. В Elixir строки являются UTF-8-бинарниками.

Создание бинарника:

<<1, 2, 3>>          # бинарник из трёх байтов
<<65, 66, 67>> # соответствует "ABC" в ASCII
"Привет" # это тоже бинарник: <<208, 159, 209, 128, ...>>

Бинарники поддерживают мощный синтаксис сопоставления с образцом, позволяющий разбирать структурированные данные на лету:

data = <<10, 20, 30>>
<<a, b, c>> = data
# a == 10, b == 20, c == 30

Можно указывать размер и тип каждого сегмента:

<<flags::8, length::16, payload::binary>> = <<1, 0, 5, "hello">>
# flags == 1
# length == 5
# payload == "hello"

Эта возможность делает Elixir особенно удобным для работы с сетевыми протоколами, файловыми форматами и другими бинарными структурами.

Конкатенация бинарников:

greeting = "Привет"
full = greeting <> ", мир!" # "Привет, мир!"

Оператор <> работает только с бинарниками и строками (которые являются их частным случаем).


Битовые строки (Bitstrings)

Битовая строка — обобщение бинарника, в котором количество битов не обязано быть кратно восьми. Бинарник — это частный случай битовой строки, где длина кратна 8.

Пример битовой строки:

<<1::3, 0::2, 1::3>>  # 8 бит → эквивалентно <<0b10001001>>
<<1::1>> # 1 бит — это уже не бинарник, а битовая строка

Проверка:

is_binary(<<1::8>>)     # true
is_binary(<<1::1>>) # false
is_bitstring(<<1::1>>) # true

Битовые строки редко используются в повседневном коде, но незаменимы при работе с аппаратными интерфейсами, шифрованием или компактными представлениями данных.


Диапазоны (Ranges)

Диапазон — это структура, представляющая последовательность целых чисел от начала до конца включительно. Записывается через ..:

1..5        # диапазон от 1 до 5
-3..0 # от -3 до 0

Диапазоны не генерируют все числа сразу — они хранят только границы. Это делает их экономичными по памяти.

Проверка вхождения:

3 in 1..5   # true
6 in 1..5 # false

Диапазоны часто используются в циклах и генераторах:

for n <- 1..3, do: n * n  # [1, 4, 9]

Отображения (Maps)

Отображение — основная структура данных для хранения пар «ключ–значение». Ключами могут быть любые типы: атомы, строки, числа, даже другие структуры. Значения также не ограничены.

Создание:

person = %{"имя" => "Иван", "возраст" => 40}
config = %{host: "localhost", port: 8080}

Обратите внимание: при использовании атомов в качестве ключей допускается сокращённая запись ключ: значение.

Доступ к значению:

person["имя"]      # "Иван"
config[:host] # "localhost"

Если ключ — атом, можно использовать точечную нотацию:

config.host        # "localhost"

Обновление значения:

updated = %{config | port: 9000}  # %{host: "localhost", port: 9000}

Этот синтаксис требует, чтобы ключ уже существовал. Для добавления нового ключа используется полная форма:

%{config | new_key: "значение"}  # ошибка, если new_key не существует
%{config | "новый_ключ" => "значение"} # тоже ошибка

# Правильно — создать новое отображение:
Map.put(config, :timeout, 5000)
# или
%{config | timeout: 5000} # только если timeout уже есть

Лучше всего использовать Map.put/3 для универсального обновления:

Map.put(%{}, :key, "value")  # %{key: "value"}

Отображения не гарантируют порядок ключей, хотя в современных версиях Elixir порядок вставки сохраняется как деталь реализации.


Переменные

В Elixir переменные — это именованные ссылки на значения. Объявление переменной происходит в момент присваивания. Синтаксис прост: имя переменной, за которым следует оператор =, и значение справа:

x = 42
name = "Елена"
status = :active

Имена переменных должны начинаться со строчной буквы или символа подчёркивания и могут содержать буквы, цифры, знаки подчёркивания и символы @ (в особых случаях). Примеры допустимых имён: counter, user_id, _temp, max_value.

Особенность переменных в Elixir — их связь с механизмом сопоставления с образцом (pattern matching). Оператор = не является классическим присваиванием, как в императивных языках. Он выражает утверждение: «левая часть должна соответствовать правой». Если соответствие возможно, переменные в левой части связываются со значениями из правой части. Если нет — возникает ошибка времени выполнения.

Пример:

{a, b} = {10, 20}
# a == 10, b == 20

Здесь переменные a и b связываются с элементами кортежа. Это не «извлечение», а декларативное утверждение о структуре данных.

Переменные в Elixir могут быть связаны только один раз в рамках одного контекста. Повторное использование имени переменной приводит не к изменению значения, а к пересвязыванию (rebinding). Это означает, что старая переменная остаётся неизменной, а новое имя указывает на новое значение. Такое поведение сохраняет неизменяемость данных, но даёт удобство в написании кода.

Пример:

x = 5
x = x + 1
# x теперь связано со значением 6
# исходное значение 5 не изменилось — просто создано новое связывание

Пересвязывание работает только с неаннотированными переменными. Если переменная используется в левой части сопоставления внутри функции или блока, где она уже была связана, интерпретатор требует точного совпадения, если не указано иное.

Чтобы запретить пересвязывание и заставить переменную вести себя как константа, можно использовать модульный атрибут или явно передать значение в замыкание без повторного связывания. Однако в большинстве случаев пересвязывание считается нормальной практикой и не нарушает функциональной чистоты.

Имена, начинающиеся с заглавной буквы, зарезервированы для модулей и не могут использоваться как переменные:

User = "admin"  # ошибка: User — это имя модуля

Переменные, начинающиеся с подчёркивания, сигнализируют о том, что значение не будет использоваться. Это соглашение помогает избежать предупреждений компилятора о неиспользуемых переменных:

{_status, data} = File.read("config.txt")
# _status игнорируется, data используется далее

Структуры (Structs)

Структура — это расширение отображения с фиксированным набором ключей, определённых во время компиляции. Структуры обеспечивают типобезопасность, читаемость и производительность при работе с именованными данными. Каждая структура привязана к модулю, и её имя совпадает с именем модуля.

Объявление структуры:

defmodule User do
defstruct name: "", age: 0, active: true
end

Создание экземпляра:

user = %User{name: "Дмитрий", age: 31}
# %User{name: "Дмитрий", age: 31, active: true}

Поля, не указанные при создании, получают значения по умолчанию, заданные в defstruct.

Доступ к полям:

user.name    # "Дмитрий"
user.age # 31

Обновление:

updated = %{user | age: 32}

Попытка добавить поле, не объявленное в структуре, вызовет ошибку:

%{user | role: "admin"}  # ** (KeyError) key :role not found

Это отличает структуры от обычных отображений и делает их подходящими для моделирования доменных сущностей.

Структуры наследуют все свойства отображений, но имеют собственный тип, что позволяет функциям проверять принадлежность к конкретной структуре:

is_struct(user)        # true
is_map(user) # true (структура — подтип отображения)

Можно определять методы внутри модуля структуры, создавая поведение, связанное с данными:

defmodule User do
defstruct name: "", age: 0

def greet(%User{name: name}) do
"Привет, #{name}!"
end
end

user = %User{name: "Анна"}
User.greet(user) # "Привет, Анна!"

Функции как тип данных

В Elixir функции являются полноценными значениями первого класса. Их можно присваивать переменным, передавать как аргументы, возвращать из других функций и хранить в структурах данных.

Функции создаются с помощью ключевого слова fn:

adder = fn a, b -> a + b end
result = adder.(3, 4) # 7

Обратите внимание на синтаксис вызова: после имени переменной ставится точка и скобки. Это отличает вызов анонимной функции от вызова именованной функции модуля.

Функции могут захватывать окружение, в котором были определены (замыкания):

multiplier = 10
scale = fn x -> x * multiplier end
scale.(5) # 50

Здесь multiplier — свободная переменная, захваченная из внешнего контекста.

Именованные функции модуля также могут быть представлены как значения с помощью оператора &:

double = &(&1 * 2)
# эквивалентно fn x -> x * 2 end

Enum.map([1, 2, 3], double) # [2, 4, 6]

# Или ссылка на функцию модуля:
formatter = &String.upcase/1
formatter.("hello") # "HELLO"

Функции в Elixir неизменяемы и не имеют состояния. Они всегда возвращают одно и то же значение при одинаковых входных данных, что делает их чистыми (pure) по умолчанию, если не используют побочные эффекты.


Процессы и PID

Elixir построен на модели акторов: каждая единица выполнения — это изолированный процесс, который взаимодействует с другими процессами исключительно через обмен сообщениями. Эти процессы управляются виртуальной машиной BEAM и не имеют отношения к операционным потокам или процессам ОС. Они легковесны, потребляют мало памяти (около 2–3 КБ на процесс) и могут запускаться в количестве миллионов на одной машине.

Каждый процесс имеет уникальный идентификатор — PID (Process Identifier). PID — это специальный тип данных, который можно получить при создании процесса:

pid = spawn(fn ->
receive do
{:hello, sender} -> send(sender, {:world})
end
end)

PID выглядит как #PID<0.123.0> и может использоваться для отправки сообщений:

send(pid, {:hello, self()})

Функция self() возвращает PID текущего процесса.

Процессы не разделяют память. Любые данные, передаваемые между ними, копируются. Это исключает гонки данных и делает программу устойчивой к сбоям: падение одного процесса не влияет на другие.

Проверка типа:

is_pid(pid)  # true

PID остаётся действительным даже после завершения процесса, но попытка отправить сообщение мёртвому процессу приведёт к его тихому игнорированию.

Процессы — основа конкурентности в Elixir. Они позволяют строить отказоустойчивые, масштабируемые и отзывчивые системы без использования блокировок, мьютексов или других примитивов синхронизации.